iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
JavaScript

QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》系列 第 16 篇

Day16|亂數:一邊是六面骰,一邊是 AI,測試方法是同一套

  • 分享至 

  • xImage
  •  

今天的主題:亂數——讓每一拳都不一樣,然後測試就變難了

到昨天為止,我的勇者每次攻擊都造成固定 17 點傷害。打四下、小怪死掉,每一場都一模一樣。今天要加入亂數,而這也讓我第一次遇到「隨機」和「可測試」的衝突。

Math.random() 只會做一件事

console.log(Math.random());   // 0.696119213070244
console.log(Math.random());   // 0.354790515352424

它回傳一個 0(含)到 1(不含)之間的小數,沒有參數、沒有選項。所有的亂數需求都得從這一個工具加工出來。

加工分兩步:

Math.random()          // 0 ~ 0.9999...
Math.random() * 5      // 0 ~ 4.9999...     ← 乘上範圍大小
Math.floor(...)        // 0, 1, 2, 3, 4     ← 無條件捨去小數

Math.floor() 是無條件捨去,不是四捨五入:Math.floor(4.99) 是 4。

我卡在公式上:為什麼是 max - min + 1?

萬用公式長這樣:

function randomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

我第一個反應是:為什麼不直接寫 Math.random() * max + min 就好?

拿骰子驗算:randomInt(1, 6) 用我的寫法是 random * 6 + 1,Math.floor 之後是 1~6——看起來完全正確。

但換成攻擊力浮動 randomInt(-5, 5) 就露餡了:

// 我的寫法
Math.random() * 5 + (-5)     // 0~4.99 再減 5 → -5 ~ -0.01
Math.floor(...)              // -5,-4,-3,-2,-1  ← 只有 5 個值,正數全部消失

// 正確寫法
Math.random() * (5 - (-5) + 1) + (-5)   // = random * 11 - 5
Math.floor(...)              // -5 ~ 5  ← 11 個值

關鍵是:要乘的是「有幾個可能的值」,不是最大值。

範圍 有幾個值 算式
1~6 6 6 - 1 + 1
-5~5 11 5 - (-5) + 1
0~4 5 4 - 0 + 1

max - min + 1 就是「數量」的公式,那個 +1 是因為兩端都要算進去。這跟 Day 5 的「陣列有 5 個元素、最後索引卻是 4」是同一家的計數問題,只是方向相反:那邊是數量換索引要減 1,這邊是起迄換數量要加 1。

而我原本的寫法只在 min = 1 時碰巧正確——這是最危險的 bug 類型:在你最常測的情況下是對的。

等一下,JS 沒有內建「指定範圍」的亂數嗎?

寫完公式我很自然地想問:這種需求這麼常見,語言難道沒有現成的?

查了之後答案是沒有。JS 內建就只有 Math.random() 這一個。

語言 直接取 1~6 的整數
Python random.randint(1, 6)
Java rand.nextInt(1, 7)
C# rand.Next(1, 7)
JavaScript 沒有,要自己組

這是 JS 的設計取向:只提供最小的原料,其他讓開發者自己做。語言核心因此很小,代價是這種天天用到的東西每個人都得重寫一次。

嚴格說還有另一個內建的 crypto.getRandomValues(),它直接給整數,而且隨機品質更高(是給密碼、驗證碼那類場合用的)。但它只能給「0~255」「0~65535」這種一整個位元組能裝的範圍,想縮成 1~6 還是得自己算,而且用取餘數去縮會產生偏差(256 除以 6 除不盡,前面幾個數字會多出現一點)。做對反而比 Math.random() 版更麻煩。

所以結論是:遊戲用 Math.random() 就好,crypto 是給安全需求的,不是給方便的。

而這也解釋了為什麼幾乎每個 JS 專案裡都有一個 utils.js:裡面裝的就是這種「語言沒有但天天要用」的小工具。我今天寫的 randomInt 之後整個遊戲都會用到——骰傷害、骰暴擊、骰道具掉落、隨機選敵人。

用 1000 次統計驗證公式

光看程式碼很難確定公式對不對,所以我跑了 1000 次骰子,統計每個數字出現幾次:

const counts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
for (let i = 0; i < 1000; i++) {
  const n = randomInt(1, 6);
  counts[n] = counts[n] + 1;
}
console.log(counts);

結果:

{1: 182, 2: 180, 3: 157, 4: 160, 5: 146, 6: 175}

插入 day16.png:主控台的 counts 統計結果

我檢查三件事:

  1. 有沒有出現 0 或 7? 沒有 → 範圍正確
  2. 6 有沒有出現? 175 次 → 那個 +1 有效
  3. 分布合理嗎? 總和 1000,每個 146~182,理論值 166.7 → 合理

第三點特別值得說:亂數不會「剛好平均」。146 和 182 差了 36 次,看起來不太平均,但這才是正常的。如果六個數字真的都是 166 或 167,反而該懷疑它根本不隨機。

對照組:故意拿掉那個 +1

為了證明 +1 真的有差,我寫了一個壞掉版:

function randomIntBroken(min, max) {
  return Math.floor(Math.random() * (max - min)) + min;   // 少了 +1
}

跑之前我先推導了一次:Math.random() 最大接近 1,所以 0.9999 * (6-1) = 4.9995,Math.floor 之後是 4,再 +1 得到 5——最大只能到 5,6 永遠不會出現。

實際跑 1000 次:

{1: 197, 2: 223, 3: 197, 4: 196, 5: 187, 6: 0}

推導完全命中。但實際數字還讓我看到更嚴重的第二層影響:

1 2 3 4 5 6
正確版 182 180 157 160 146 175
少了 +1 197 223 197 196 187 0

插入 day16-2.png:壞掉版的統計結果,6 是 0

那消失的 1/6 不會憑空不見,它們全部擠進剩下的五個值裡——原本每個約 167 次,現在變成約 200 次。

換句話說,這個 bug 的影響是雙重的:一個值消失,而且其他值的出現機率全部被拉高 20%。

如果這是遊戲的傷害骰,玩家不只永遠打不出最高傷害,連中等傷害出現的頻率都偏高了。整個遊戲的手感被改變,但沒有任何一次攻擊看起來是壞的——又一個安靜失敗,而且這次只有統計才看得出來。

一個自己造成的副作用

我為了看清楚,在 randomInt 裡加了一行 console.log。結果那個函式被呼叫了 1000 次,主控台瞬間被灌了 1000 行。

這是 Day 7 那個無窮迴圈的小型版:在高頻呼叫的函式裡 log,會瞬間淹沒主控台。驗證完就該拿掉,不然之後打一場戰鬥主控台會吵到看不到重點。

接進戰鬥

function calcDamage(atk, def) {
  const base = Math.max(1, atk - def);
  const variance = randomInt(-3, 3);      // 浮動 ±3
  return Math.max(1, base + variance);    // 保底 1 點還是要留著
}

實際打一場:

造成 14 點傷害!
造成 16 點傷害!
造成 19 點傷害!
小怪 被擊敗了!

插入 day16-3.png:戰鬥畫面,每次傷害數字都不同

基礎 17、浮動 ±3,實際範圍是 14~20,三次結果都落在裡面。這場一樣打了四下,但過程不再一樣了。

注意外層那個 Math.max(1, ...) 還留著——Day 8 定的保底規則,在加了負數浮動之後更重要了。

然後我發現測試變得很麻煩

寫完之後我下意識想的第一件事是:這樣還要怎麼測?

以前我可以說「按四下,小怪死掉」。現在同一組操作,每次結果都不同。

但我想了一下,發現自己一開始問錯了問題。我原本想的是「怎麼確保勇者一定能贏」——可是如果做出的遊戲 100% 保證勝利,那就沒意義了。不確定性是這個產品的特性,不是缺陷。為了好測而讓遊戲必勝,那是犧牲產品去遷就測試。

所以真正的問題不是「怎麼讓結果固定」,而是:結果不固定的東西,要怎麼驗證它是對的?

我的答案:不驗證「答案是什麼」,驗證「規則有沒有被違反」

如果玩家回報「我打了 10 下都沒贏,是不是壞掉了」,我沒辦法靠重現來判斷——重現一次也是隨機的。我的做法會是裝監控,記錄這 10 下、100 下骰出的每一個數字,然後看有沒有哪個值從來沒出現過,也就是檢查會不會又是那個少了 +1 的錯誤。

這正是今天練習 3 做的事。當時只覺得是在「檢查公式」,回頭看才發現:那 1000 次統計本身就是一種測試方法——不驗證單次結果,而是驗證整體性質。

這種「必須永遠成立的性質」在工程上叫不變量(invariant)。這個遊戲目前的不變量有:

傷害永遠 >= 1
傷害落在 14 ~ 20 之間
血量不會變成負數
骰出的值都在指定範圍內

每一條都跟「這一次會骰到幾」無關,但每一條都必須永遠成立。

這件事我在工作上每天都在做

想到這裡我才意識到:我的工作有一項就是測 AI 聊天,而 AI 的回答完全不固定。

我們的做法是:給一些模板讓它有個參照,再定義一些禁止的話題,確認它有沒有講出關鍵字。

這跟今天做的事一模一樣——不去驗證「它到底回了什麼」,而是驗證「它有沒有違反規則」。模板是參照的錨,禁語清單是絕對不能違反的不變量。

一邊是六面骰,一邊是語言模型,兩者的隨機程度天差地遠,但測試的方法是同一套。我做了這麼久卻沒想過它們是同一件事,今天用一個骰子函式把它想通了。

一個統計上的陷阱

不過「裝監控看分布」還有一個前提:樣本數要夠。

  • 骰 10 次沒出現 6 → 完全正常(大約每六次就有一次會這樣)
  • 骰 1000 次沒出現 6 → 幾乎確定是 bug

所以玩家說「我打 10 下都沒贏」,單憑這句話真的無法判斷。樣本太小的時候,正常的運氣和真的壞掉長得一模一樣。這也是為什麼處理不確定系統的 bug,不能靠「重現一次看看」,而要靠累積資料。

小結

今天搞懂的事:

  • Math.random() 只回傳 0~1 的小數,所有亂數都從它加工
  • Math.floor() 是無條件捨去,不是四捨五入
  • 整數亂數公式 Math.floor(Math.random() * (max - min + 1)) + min,要乘的是值的數量不是最大值
  • JS 沒有內建「指定範圍」的亂數(Python/Java/C# 都有),要自己寫成工具函式重複用
  • 少一個 +1 的後果是雙重的:最大值永遠不出現,其他值機率被拉高
  • 亂數「不完全平均」才正常;太平均反而可疑
  • 在高頻函式裡 console.log 會瞬間灌爆主控台
  • 測不確定的系統,不驗證「答案是什麼」,要驗證「規則有沒有被違反」
  • 樣本太小時,運氣和 bug 長得一樣

戰鬥終於有變化了。不過小怪到現在還是站著挨打——明天讓牠反擊,遊戲才會有真正的輸贏。


上一篇
Day15|我在自己的程式裡找到一個真的漏洞
下一篇
Day17|計時器:地雷、定時炸彈,和我決定不用它的理由
系列文
QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言